Skip to content

feat(working-memory): measure working memory in admin calibration runs - #9897

Open
Pfannkuchensack wants to merge 23 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/working-memory-calibration-runs
Open

Pfannkuchensack wants to merge 23 commits into
invoke-ai:mainfrom
Pfannkuchensack:feat/working-memory-calibration-runs

Conversation

@Pfannkuchensack

@Pfannkuchensack Pfannkuchensack commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Stacked on #9896. Until that merges, the diff also shows its commit; this PR's change is 470db713f5.

Summary

Working-memory estimates can now be checked against what an operation really uses, on the machine that runs it. This PR only records measurements; no estimate changes.

  • Probe (invokeai/backend/working_memory/probe.py)
    • During a calibration run, every model_on_device(working_memory=...) window records its demand next to its estimate.
    • Demand is max(own-process driver delta, torch reserved-peak delta, allocated-peak delta), measured from a released baseline.
    • The driver delta comes from D3DKMT on Windows (NVIDIA and AMD), NVML or fdinfo on Linux, and the driver counter on MPS.
    • It locks with headroom, so a too-low estimate cannot spill while it is being measured.
    • It handles co-locked models and nested windows.
    • It marks a window invalid on a foreign lock on the same device, OOM, allocator retries, persistent spill to shared memory, exceptions, or probe errors.
    • Partial residency keeps a window but excludes it from fitting.
    • Without a calibration scope the cost is one ContextVar read per estimated lock.
  • Runs (invokeai/app/services/working_memory_calibration/)
    • An admin enqueues a calibration batch through POST /api/v2/models/i/{key}/working_memory/calibrate.
    • Its items carry a reserved origin that enqueue_batch refuses for everyone.
    • At session start the runner re-admits each item: active admin owner, exactly one GPU generation device, run present for this device class.
    • It opens the probe scope per node and stores the windows in a JSON file per device class under databases/working_memory/.
    • The file is written atomically and under a file lock; files it cannot read are kept and never overwritten.
    • Status, reset and export routes are admin-only.

Related Issues / Discussions

Builds on #9896.

QA Instructions

  • Tests: affected backend and app suites (working_memory, util, model_manager/load, the calibration service, runner and router tests, session queue/processor): 2079 passed, 115 skipped. Each new file also passes alone.
    • Mutation checks: co-lock offset, nested suspend, foreign lock at open, RAM-pin and unlock bookkeeping, admission refusals, retry lineage and the store's OS lock are each caught.
  • Hardware: pytest -m slow tests/backend/working_memory/test_probe.py passed on an RTX 4090 (CUDA 13.0) and an RX 9060 XT (ROCm 10, Windows): the window is valid and covers the reserved growth.
  • End to end on the RTX 4090 (server from this branch, cudaMallocAsync):
    • A FLUX VAE encode→decode at 1024² through the calibrate endpoint completed and recorded two valid, verified windows.
      • flux_vae_encode: reserved 2200 MiB, measured 1840 MiB.
      • flux_vae_decode: reserved 4400 MiB, measured 2912 MiB.
    • The status call did not change the process's VRAM.
  • API: regenerated openapi.json/schema.ts; pnpm lint and pnpm test in invokeai/frontend/api pass.

Review

Resolved findings

  • Runs could report "running" forever after Clear Queue, a crash or a prune.
    • Fix: the outcome is recorded in finally, and an item that is gone counts as canceled.
  • Probe errors could leave windows valid with corrupted figures.
    • Fix: every hook, observer or close failure marks the affected windows.
  • A foreign lock could slip in between the lock count and window registration at open.
    • Fix: the window is registered before the count.
  • calibrate was not safe to retry after a lost response.
    • Fix: it takes an idempotency key.
  • Windows os.replace failed transiently while another process held the file.
    • Fix: bounded retry.
  • The ROCm attention and conv3d switches were missing from the environment fingerprint.
  • Status available and the declared error codes now match what the routes do.

Remaining limits

  • The Linux NVML reader is verified on hardware (RTX PRO 6000 Blackwell, Ubuntu 26.04; feat(working-memory): measure working memory in admin calibration runs #9897 (comment)). The Linux fdinfo, MPS and XPU readers follow vendor documentation, untested on hardware; they are marked verified=false.
  • Direct torch.cuda.empty_cache() calls and background offloads bypass the segment boundary. The torch peaks still bound the demand.
  • Multi-GPU calibration is refused by design.

Compatibility / Rollout

  • New admin-only routes; regenerated API artifacts.
  • No database migration. Calibration data lives in <db_dir>/working_memory/<device_class>.json.
  • Nothing reads it for estimates yet.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

VAE estimators return a WorkingMemoryEstimate built from fitted and analytic terms; model_on_device accepts it as working_memory=.
The AutoencoderKL coefficients and their measured points move to backend/working_memory/data, checked by a data test; reservations are unchanged.
A probe records each estimated model_on_device window's device-level demand inside a calibration scope; results go to a per-device-class store under databases/working_memory.
Admin endpoints start, inspect, reset and export runs; the reserved queue origin is refused for ordinary enqueues.
@github-actions github-actions Bot added api python PRs that change python files Root invocations PRs that change invocations backend PRs that change backend files services PRs that change app services python-tests PRs that change python tests docs PRs that change docs python-deps PRs that change python dependencies labels Oct 17, 2026
…n fact

Environment ids now include windows, linux or other, so calibrations from different operating systems are kept apart; the OS build string moves to soft fact os_build.
# Conflicts:
#	invokeai/backend/util/vae_working_memory.py
#	tests/app/invocations/test_flux2_working_memory.py
# Conflicts:
#	docs/src/content/docs/development/Architecture/model-manager.mdx
#	invokeai/backend/model_manager/load/load_base.py
# Conflicts:
#	invokeai/app/services/invocation_services.py
… tests

CPU wheels on macOS and Windows have no CUDA allocator binding, so the fixture that scripts a CUDA build scripts its backend too.
@lstein lstein added the 7.0.0 label Oct 19, 2026
@lstein lstein moved this to 7.0 Theme: Modular Design in Invoke - Community Roadmap Oct 19, 2026
@JPPhoto
JPPhoto self-requested a review October 20, 2026 17:13

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pfannkuchensack Your review has arrived. Merge blockers:

  1. Pending calibration work can fail during startup. invocation_services.py:152 starts the session processor before the calibration service. A resumed queue item can reach admit() before the calibration service sets _invoker in working_memory_calibration_default.py:178; the item then fails before measurement. Start the calibration service before session workers, or remove that ordering dependency. If skipped: a restart with pending calibration work can lose it. Likelihood: only when work is pending at startup. Recovery: rerun the failed request. Test: boot with a pending calibration item and verify it is not failed before service initialization.

  2. Failed or canceled nodes can produce valid measurements. session_processor_default.py:314 wraps run_node() in the probe scope, but run_node() catches and swallows errors and cancellations at session_processor_default.py:462. The scope therefore closes without an error, so its windows can remain valid. Propagate the node outcome to the scope. If skipped: failed executions can be treated as trustworthy calibration data. Likelihood: whenever a measured node fails or is canceled. Recovery: remove the affected measurements and rerun. Test: fail and cancel an invocation through DefaultSessionRunner and assert its windows are invalid.

  3. An idempotency conflict can delete a run another request is using. In working_memory_calibration_default.py:314, the request that first creates a run deletes it on enqueue failure. A concurrent request with the same key but different payload can enqueue successfully first; the creator then deletes the shared run. Cancellation during the threaded enqueue has a similar risk. Protect creation and enqueue as one operation, and only discard a run when no enqueue committed. If skipped: queue items can lose their run and measurement provenance. Likelihood: concurrent or canceled same-key requests. Recovery: remove orphan queue items and rerun. Test: race different payloads under one key and cancel during enqueue.

  4. A key does not uniquely identify the calibration request. The run ID uses user and idempotency key, while the queue payload hash omits the model route. Reusing the same key and graph for model B after model A can return model A’s run as a success. Also, queue receipts expire after seven days, so a later retry can enqueue again into the persistent run. Persist and compare a request fingerprint that includes the model key and batch data. If skipped: results may have the wrong model provenance or duplicate samples. Likelihood: key reuse across models or after receipt expiry. Recovery: discard the incorrect run and retry with a new key. Test: reuse a key across model paths and after receipt expiry.

  5. Partial batch admission is reported as success. working_memory_calibration_default.py:316 rejects only when zero items enqueue. The queue can admit only the available capacity, so a multi-item request can complete with a partial sample set. Require all items to enqueue or mark the run incomplete and clean up admitted items. If skipped: incomplete calibration data can appear complete. Likelihood: queue near capacity. Recovery: discard the partial run and rerun. Test: submit multiple batch values with capacity for only one.

Other findings/issues:

  1. Counter failures can still yield valid windows. counters.py:204 swallows peak-reset and synchronization errors; callers continue measuring in probe.py:402 and probe.py:519. Return failure or raise a probe-local error and mark the window invalid. If skipped: stale peaks or unsettled GPU work can contaminate trusted measurements. Likelihood: transient device/backend errors. Recovery: remove the affected point and rerun. Test: make reset or synchronization fail and assert the window is invalid.

  2. A lock can miss probe hooks during an arm race. load_base.py:193 snapshots probe.armed before locking. If it reads false, pauses, then the probe arms and opens a window, the lock proceeds without hooks and can contaminate that window. Coordinate the snapshot and hook registration, or ensure the hooks cover the interleaving. If skipped: concurrent model loading can be included in valid demand. Likelihood: narrow thread interleaving during calibration. Recovery: rerun the measurement. Test: use a barrier to force the interleaving.

  3. The generated API contract omits a new HTTP 400. session_queue.py:272 returns 400 for a reserved origin, but the route declaration and generated openapi.json:12884 do not list it. Declare the response and regenerate the API artifact. If skipped: generated clients and docs miss a supported error response. Likelihood: client depends on generated contract. Recovery: regenerate and update clients as needed. Test: verify the route’s OpenAPI response includes 400.

…stworthy

The session processor starts after every other service; windows of failed or canceled nodes, failed counter resets and locks racing a window are stored invalid.
Keyed requests are identified by model, graph and batch data, are created once per run and admit all of their items or none; the reserved-origin 400 is declared.
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

@JPPhoto Thanks for the second pass. All six points are addressed in b012444. Each one has a test that fails without its fix.

High: shutdown overlapping old workers

  • DefaultSessionProcessor.stop() now joins the workers against one shared 30 s deadline, after it signals stop and cancels their sessions. If it is still waiting after 1 s, it logs which worker and queue item it is waiting for.
  • A worker still in its node at the deadline is logged as an error and recorded as abandoned. A repeated stop() does not wait for it again.
  • While an abandoned worker is alive:
    • register_generation_devices() and start() refuse with a RuntimeError, so nothing replaces the device pool or starts a second worker on that GPU.
    • ApiDependencies.initialize refuses before it opens the database.
  • The database is still disposed after the stop. A late transaction from an abandoned worker is refused by the database's thread claim.
  • Why bounded: an unbounded join hangs shutdown forever on a node that never reaches a cancel point. The restart refusal closes the overlap a bounded join would otherwise leave.
  • Tests: tests/app/services/test_invoker.py and tests/app/test_api_dependencies.py.

A detached locked record escaping the probe

  • ModelCache.locks_held() now reads a counter that every lock and unlock keeps, independent of cache membership. That covers the lock/continue_lock failure paths too.
  • CacheRecord.unlock() refuses an unlock without a lock before it decrements, so the counter cannot drift.
  • Tests: test_model_cache_locks_held.py, including a dropped-while-locked record and both OOM paths.

reset_model undone by a pending write

  • A reset raises a per-model epoch in the store file (model_reset_epochs), in the same file change, so the epoch exists only if the reset committed.
  • The runner reads the epochs when the calibration node starts, before the probe scope opens.
  • The write drops, under the file lock, every point attributed to a model whose epoch moved since then, including submodel windows such as <key>:vae.
  • This also holds for a reset made by another process on the same root.
  • Tests: a reset while the node runs, a reset by a second service instance, an in-flight write, and submodel attribution.

Retry lineage before admission

  • The run's live read now returns retried_from_item_id (SessionQueueBase.get_queue_item_lineage, one indexed statement whose plan is pinned by a test). A retry stands in for its item before the runner starts it, and a canceled retry counts as the run's outcome.
  • Remaining limit, documented: a retry that the queue prunes or clears before the runner ever saw it leaves no trace, so the run reads as the original item's outcome. Covering that would need a hook where the queue creates retries.

Keyed recovery after the model was removed

  • When the run is recorded without items and the key's receipt exists, _prepare_run replays the receipt before any model or device lookup.
  • A new request still requires the model.

Reset not invalidating the idempotency key

  • A reset adds the removed keyed runs to removed_run_ids in the store file. Unkeyed runs are not added, since they cannot be repeated.
  • A repeat of the keyed request after a reset is refused with the existing 404, whether the receipt is retained or already collected, on every device class.
  • The reset run's pending items keep failing at admission.

Store file: the two new fields (removed_run_ids, model_reset_epochs) have empty defaults. The schema version stays 1, so existing files read unchanged.

Contract: no API shape or status changes. The OpenAPI descriptions are regenerated.

The stack above (#9899, #9910, #9919, #9927, #9946) has these merged in.

@JPPhoto
JPPhoto self-requested a review October 21, 2026 11:12

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks; the six findings from the previous review appear fixed.

Merge blockers

  1. Medium - Status can say calibration is available when the store cannot be written. In invokeai/app/services/working_memory_calibration/working_memory_calibration_default.py:671-693, status checks whether the store file can be read, not whether a run can write it. A read-only store can report available=true, then fail run creation with 503. Also, device_class_ids() can let a directory PermissionError escape as 500 (working_memory_calibration_store.py:140-144). Cover read-only and unreadable store directories.

  2. Medium - A keyed retry can enqueue a second batch after an ambiguous failure. If queue admission succeeds but recording item IDs fails, the run remains with no IDs. If the key is acknowledged after that 503, its receipt expires, and the queue items are cleared or pruned before observation, a repeat falls through to enqueue at working_memory_calibration_default.py:423-450. Preserve an admission marker or refuse to re-enqueue when admission is uncertain. Test that sequence.

Other issues

  1. Low - A completed item can later appear canceled or partial. The queue records completion before the processor persists the terminal calibration observation (session_processor_default.py:660-675,327-330). If the process stops in between and the completed row is later pruned, the saved in_progress observation is treated as canceled (working_memory_calibration_default.py:151-152). Test completion followed by pruning before observation.

  2. Low - A conflicting key can return 404 instead of the documented 409. With a different request that names a missing model, _prepare_run() looks up the model before queue admission detects the key conflict (working_memory_calibration_default.py:423-450). The endpoint documents 409 for key reuse with another request (invokeai/app/api/routers/working_memory.py:95-100).

  3. Low - Run summaries can omit a retry's source item. derive_run_status() uses retried_from_item_id to determine effective status but does not add that source ID to the returned item_ids (working_memory_calibration_default.py:144-166). If the source is absent from stored, observed, and live IDs, the summary violates its "every queue item" contract.

…in calibration runs

Status reports a store it cannot write as unavailable, and a run is marked before its batch is enqueued so a repeat never enqueues it twice.
A clean finish is recorded before the queue completes the item, a key conflict answers 409 before the model lookup, and run summaries list a retry's source.
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

@JPPhoto Thanks. All five points are addressed in 1370b23, and each has a test that fails without its fix.

1. Status available on a store it cannot write. Status now checks that the current device class's store can be written. It opens an OS-deleted temporary file in the store folder, or in the nearest existing folder above it, and checks that the lock file is writable. On Windows it also checks that the JSON file is not read-only. A store that fails this reports available=false with the reason. device_class_ids() wraps listing errors as a store error, so an unreadable store directory answers 503 instead of 500. Tests cover a read-only lock file, a store root that is a file, and an unlistable directory.

2. A second batch after an ambiguous failure. A run now carries enqueue_attempted. A new run is recorded with it already set, in the same write. Only the attempt that set it clears it, and only when its enqueue is known to have queued nothing. A receipt replay never clears it. A repeat that finds a marked run with no items, no receipt and nothing queued or observed enqueues nothing. It returns the run with empty item_ids, and the run reads canceled. Tests: your sequence; a replay whose enqueue raises before the receipt is collected; and a full queue followed by a later repeat.

3. A completed item later reading canceled. On a clean finish (no error, not canceled, and the queue row is neither failed nor canceled), the runner now records the item as completed before the queue completes it. The final observation still writes the queue's status. Tests:

  • the outcome survives a prune;
  • the order is the runner's write, then the queue's;
  • a failing node, and a failed save, are not recorded as completed.

5. 404 instead of 409. For a keyed request with no run, the reset check runs first (404), then the receipt check (409), and only then the model lookup.

7. Retry sources missing from item_ids. derive_run_status adds every lineage source to item_ids.

Limits, documented:

  • If the process stops between the runner's completed record and the queue's completion, and the row is later pruned, the run reads completed. All of that item's nodes did finish.
  • If clearing the mark fails, it is only logged. The run then stays marked, so that key cannot start it, though a request with another key can.

Store file: one new field with a default; schema version 1. API: no shape or status changes. The OpenAPI descriptions are regenerated.

The stack above has this merged in.

@JPPhoto
JPPhoto self-requested a review October 21, 2026 21:10

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings:

  1. Medium - Cross-process requests can return an empty success or enqueue twice. The per-run lock is process-local. If process A records the marked run and pauses before enqueue, process B can return 201 with item_ids=[] at invokeai/app/services/working_memory_calibration/working_memory_calibration_default.py:437-464; A may then enqueue or remove the run. If two prepared attempts overlap and one resumes after the first receipt is collected, both can enqueue, and the later item IDs can replace the earlier ones (:369-382,492-503). Use cross-process coordination or an explicit in-progress response. Add a two-process regression for both sequences.

  2. Medium - Final observation can overwrite a saved completion with canceled. The runner records completion before queue completion, but its finally block calls observe() afterward (invokeai/app/services/session_processor/session_processor_default.py:320-341). If the completed queue item is cleared or pruned in between, observe() records canceled over the saved completion (invokeai/app/services/working_memory_calibration/working_memory_calibration_default.py:619-633,650-655). Preserve an existing completed observation and test that sequence.

A keyed run's creation holds an OS file lock, striped over 256 files, from preparing the run to recording its items, behind a per-run in-process gate.
The final observation keeps a recorded completion of an item the queue no longer has.
…waits for its workers

Stop now joins the workers, so the wake-up test opens its gates before stopping and runs the stop case on a thread of its own.
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

@JPPhoto Thanks. Both points are addressed in 9f2345c, with tests that fail without the fixes.

1. Cross-process creation

  • A keyed run's creation now holds an OS file lock from preparing the run to recording its items. It covers the enqueue and the cleanup when nothing was queued.

  • The lock uses the store's own lock primitive (msvcrt.locking / flock), so a holder that dies releases it with its process.

  • It is striped over 256 files, working_memory/creations/<xx>.lock, where xx comes from the run id's sha256. That bounds the folder and means no lock file is ever deleted, which would race. The cost is false contention between unrelated runs that share a stripe, which is negligible for this admin action.

  • A per-run in-process gate sits in front of it, so concurrent repeats in one process wait on the event loop rather than each holding an executor thread.

  • A contender waits up to 30 s and then gets the existing retryable 503. After acquiring the lock it sees what the first attempt did:

    • the recorded items;
    • the receipt to replay;
    • or, once that receipt is collected, the marked run.

    So it can neither return an empty success while the first attempt is enqueuing, nor enqueue a second batch, nor replace the first attempt's item ids.

  • Tests: two service instances on one root.

    • A repeat waits while the first instance is held inside its enqueue.
    • A second request cannot prepare while the first is recording the run.
    • Eight concurrent repeats in one process leave one worker on the file lock.

    Separate instances model separate processes here, because the OS arbitrates these locks per handle (LockFileEx) and per open file description (flock).

2. Final observation overwriting a saved completion

  • When the queue no longer has the item, the final observation keeps a recorded completion.
  • A queue row that still exists stays authoritative, including a failed one. So the run reads the same before and after a prune.
  • Tests: completion recorded, item pruned, then the final observation; and a row that is failed in the queue.

Also changed: main's #9855 wake-up test. Merging main brought in #9855's test_worker_wakeup.py. Two of its places assumed that stop() returns while workers are still parked on test gates. Since stop() now joins its workers, the test (62d75d8):

  • opens its gates before the cleanup stop;
  • runs the stop case on a thread of its own, continuing once every worker's own notification is set.

#9855's guarantee is unchanged. Making the workers share one poll_now_event again fails all three cases.

The stack above has this merged in.

@JPPhoto
JPPhoto self-requested a review October 22, 2026 14:12

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more low-priority change:

  • Clearing the queue can leave a canceled run marked completed. The runner records completion before it finishes (invokeai/app/services/session_processor/session_processor_default.py:320-341). If queue clear happens in that interval, it cancels and deletes the row (invokeai/app/services/session_queue/session_queue_default.py:611-621). The final observation then preserves completed because the row is missing (invokeai/app/services/working_memory_calibration/working_memory_calibration_default.py:647-663). Preserve clear cancellation separately from pruning.

…ue completion as canceled

The runner tells the final observation whether the queue committed the item's completion, so a cleared row overrides a completion recorded ahead of the queue while a pruned completed one keeps it.
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

@JPPhoto Thanks, fixed in 9f87a27.

The fix

  • The runner now passes queue_completed to the final observation: whether it saw the queue commit the item's completion, from the item _on_after_run_session returns.
  • For an item the queue no longer has:
    • with queue_completed, the observation stays completed, so a completed item that is pruned keeps its outcome;
    • without it, it is canceled. That overrides a completion the runner recorded ahead of the queue, which covers a Clear Queue in that interval.
  • A queue row that still exists keeps the last word, as before.
  • I did not use the worker's cancel event. Clear Queue's handlers set it asynchronously, so it can arrive after the final observation.

Tests

  • A runner test runs clear between the recorded completion and the queue's completion. The observation is canceled.
  • A service test covers three cases: pruned after completion (completed), cleared before completion (canceled), and failed in the queue (failed).

Remaining limit (documented): if completing the item raises after the queue's commit, a prune or clear in the gap before the final observation records canceled. While the row exists it reads completed.

The stack above has this merged in.

Measured on an RTX PRO 6000 Blackwell: NVML's per-process delta equals reserved growth, the first window adds first-use memory.
The hardware test asserts the verified nvml method on Linux CUDA; docs note that containers without the host PID namespace fall back to torch-only.
@Pfannkuchensack

Pfannkuchensack commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

Linux CUDA test run. Machine: Verda 2× NVIDIA RTX PRO 6000 Blackwell Server Edition (96 GB, sm_120), driver 580.178.04, Ubuntu 26.04.1, torch 2.13.0+cu130, cuDNN 9.20, Triton 3.7.1. The PR head was compared with its merge base on main; each GPU ran one lane, pinned by UUID.

Linux NVML reader, run on hardware

  • pytest: tests/backend/util/test_nvml.py tests/backend/working_memory: 142 passed, including the slow hardware probe test.
  • Probe scenario (SD VAE decode on this GPU):
    • The method is nvml and the driver delta is present.
    • The NVML per-process value matches nvidia-smi.
    • At 1024² the driver delta equals the reserved delta.
    • The first window (768²) reads +110 MiB over the reserved delta. That is first-use memory outside the allocator, gone in the next window.
  • E2E start --calibrate through feat(e2e): calibrate working memory and build shipped data from probe runs #9899: 24 valid windows, all method nvml. scripts/working_memory/build_shipped.py reads the export.
    • Against the RTX 4090 Windows points under the same native allocator (shipped in vae.json), SD VAE fp32 decode measures 0.98× at 1024² and 0.99× at 1536², and the shipped estimate covers every Linux point (SD fp32/fp16, FLUX bf16 up to 3584²) with 6–10 % headroom. So the OS-independent (cudnn, native) keying holds here.
  • Done in edba0dd: ("nvml", "cuda") is now in VERIFIED_METHODS, so new Linux NVIDIA windows say verified=true. The hardware test asserts that on Linux CUDA, and the docs note that containers without the host PID namespace read torch-only.

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings/issues:

  1. invokeai/app/services/session_processor/session_processor_default.py:323-345: The runner saves completed before queue completion. If Clear Queue deletes the row, then the process stops before the final observe(), working_memory_calibration_default.py:151-155 uses the saved completed status. The run appears completed even though Clear Queue canceled it. Effect: admins see a false success. Likelihood: rare clear-and-stop race. Recovery: reset the run; the deleted queue row cannot restore the outcome. Test: clear after observe_completed(), stop before final observation, then verify status after restart.

  2. invokeai/app/services/working_memory_calibration/working_memory_calibration_default.py:653-667: If a failed queue row is pruned before final observation, the missing-row path records canceled because the runner passes only queue_completed. This loses the failure status and error. Effect: admins cannot diagnose the run from its status. Likelihood: narrow race with prune or clear after failure. Recovery: rerun and check server logs. Test: fail an item, prune it before observe(), and verify the run retains failed and its error.

  3. tests/backend/working_memory/test_probe.py:973-975: The Linux CUDA hardware test requires NVML to be visible and verified. In a supported container without the host PID namespace, NVML cannot match this process and counters.py:181-197 falls back to torch-only; the docs describe that fallback at model-manager.mdx:1070. The hardware test then fails despite expected product behavior. Effect: false failure in that environment. Likelihood: any CUDA container without host PID visibility. Test: run the slow probe test in such a container; allow the documented fallback or skip the NVML-specific assertion.

  4. docs/src/content/docs/development/Architecture/model-manager.mdx:1095: The paragraph says a completed item stays completed after a later clear, then says any item removed by Clear Queue is recorded as canceled. These statements conflict. Effect: maintainers get unclear status semantics. Test: clarify the before-completion and after-completion cases in the paragraph.

…tribute submodel windows on reset

The runner no longer records completion ahead of the queue and hands the final observation the item as the queue settled it, so a cleared item never reads completed and a pruned failure keeps its error.
Resets and the write fence attribute `<key>:<submodel>` windows to their model, and the NVML hardware test accepts the documented torch-only fallback.
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

@JPPhoto Thanks — all four points are addressed in 49d8f8e, each with a test that fails without its fix.

1. Clear Queue, then a stop, left a false completed. I removed the early completion record (observe_completed) instead of making it provisional. Once the row is gone, a provisional record cannot be confirmed without a write on a status read, and reading it as canceled has the same outcome as having no early record. The trade-off: if the process stops after the queue completed an item and the item is then pruned before a restart, the item now reads canceled (the run partial if it measured valid points). Before, it read completed, so this reverses my round-3 fix for that window. It errs toward an honest outcome instead of a false success, and the measured points are kept. Test: clear just before the queue completes the item, the final observation lost, then a fresh service reads the run. It never reads completed.

2. A pruned failure lost failed and its error. The runner now hands the final observation the item as the queue settled it. It is captured right after the queue's completion or failure step, before statistics and callbacks run. When the row is gone the item keeps the outcome the queue gave it, completed or failed with its error. If the queue never settled it, the item reads failed when the runner raised and canceled otherwise. A row that still exists keeps the last word. Tests:

  • a failure pruned before the observation;
  • a runner error after Clear Queue;
  • a session callback that raises after completion, followed by a prune.

3. NVML in containers without host PID visibility. On Linux CUDA the hardware test now asks NVML itself. If it finds no entry for this process, the test asserts the documented torch-only fallback. Where NVML matches, the full NVML assertions still apply.

4. The contradictory doc paragraph is rewritten. It now separates three cases: the queue settled the item before it was removed, Clear Queue removed it first, and the runner raised. It also states the stop-then-prune limit.

Also from @joshistoast's review on #9899: resets and the write fence now attribute <key>:<submodel> windows (e.g. model-a:vae) to their model. A model reset therefore removes them, and an in-flight write of them is fenced. The stored format is unchanged.

@JPPhoto
JPPhoto self-requested a review October 23, 2026 01:47

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other findings/issues:

  • invokeai/app/services/session_processor/session_processor_default.py:315-321, 666-676, 775-777: Terminal outcomes can be lost before observation. If a refusal or node failure is pruned before _on_after_run_session(), the run records canceled; if completion commits but status-event handling raises and the row is then pruned, it can record failed. Likelihood: narrow prune or event-error race. Recovery: capture each returned terminal item immediately and recover committed status after transition errors. Test both races.
  • invokeai/backend/util/nvml.py:118-122: In a private PID namespace, os.getpid() can numerically match an unrelated host GPU process. NVML can then attribute that process's memory to InvokeAI and mark the measurement verified. Likelihood: rare but possible. Recovery: verify the host PID mapping and fall back to torch-only when it is unknown. Test a colliding PID.

…ust NVML only in the host PID namespace

Each terminal queue transition hands the runner the item it settled, and a transition whose announcement raised reads its committed status back.
NVML attributes memory only when the process runs in the initial PID namespace; elsewhere the counters fall back to torch-only.
@Pfannkuchensack

Copy link
Copy Markdown
Member Author

@JPPhoto Thanks, both are addressed in b80d534, each with a test that fails without its fix.

1. Terminal outcomes lost before observation. Every terminal queue transition in the runner now hands it the settled item at once. That covers the refusal, a revoked owner's cancel, a failed save of measurements, the node-error fail in _on_node_error, and the completion. If a transition raises after its commit, for example because the status announcement fails, the runner reads the row back and keeps it when it is terminal. A later prune therefore keeps the outcome the queue gave the item.

Tests: a refusal pruned before the final record, a node failure pruned before the session ended, and a completion whose announcement raised, then pruned.

Remaining limit: if the row is pruned in the moment between a raising announcement and the read-back, the item falls back to the runner's error and reads failed.

2. NVML in a private PID namespace. NVML attributes memory only when the process runs in the initial PID namespace, i.e. /proc/self/ns/pid has the init inode 0xEFFFFFFC. Elsewhere, including when the link cannot be read, the counters fall back to torch-only and the window is not marked verified. Bare-metal hosts and containers with --pid=host keep NVML.

I did not use NSpid: inside a container its first entry is the container's own id, not a host PID, so it would trust the same unknown mapping. The cost is that a container which shares the host's procfs but not its PID namespace also reads torch-only.

Test: a colliding PID in a namespace of its own reads unknown.

JPPhoto and others added 2 commits October 23, 2026 11:58
Retain the committed queue snapshot when event publication fails and pruning wins the read-back race.
Assert hardware probe metadata from the measured window.
@JPPhoto
JPPhoto self-requested a review October 23, 2026 20:21

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must fix

  1. MPS reset can retain an earlier peak (invokeai/backend/working_memory/counters.py:265-267,285-289)

    • What this is: A background sampler tracks MPS memory peaks between resets.
    • Problem: A sample captured before reset can be folded afterward, making the previous operation's memory look like demand from the new one. A controlled interleaving reproduced this: a sample of 100 folded after reset at 10 left the peak at 100.
    • Fix: Serialize sampling and folding with resets, and add a barrier-controlled regression test.
    • If we skip it: Valid calibration records can contain inflated measurements.
  2. A canceled run can become failed (invokeai/app/services/working_memory_calibration/working_memory_calibration_default.py:681-686)

    • What this is: When the queue row is gone, observe() uses the runner's saved terminal item to recover the outcome.
    • Problem: If Clear Queue leaves a canceled snapshot and a later callback raises, observe() ignores the canceled snapshot and records the callback error as failure.
    • Fix: Accept a saved canceled item as terminal before applying the callback error; add a regression test for cancellation, callback failure, and a missing queue row.
    • If we skip it: The same canceled run can show as failed depending on callback and prune timing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

7.0.0 api backend PRs that change backend files docs PRs that change docs invocations PRs that change invocations python PRs that change python files python-deps PRs that change python dependencies python-tests PRs that change python tests Root services PRs that change app services

Projects

Status: 7.0 Theme: Modular Design

Development

Successfully merging this pull request may close these issues.

3 participants